App Initialization
AppInitializer in toolkit:initializer coordinates app startup and exposes its current state.
It is an AppScope singleton exposed by AppComponent.appInitializer. The shared app maps this state
to a running screen, a failure screen with retry, or navigation content.
Execution Order
Each accepted initialize() command runs two phases:
- Core
Initializerimplementations run sequentially onAppDispatchers.main, in ascending integer-key order. - If every core initializer succeeds,
AsyncInitializerimplementations run sequentially onAppDispatchers.default, also in ascending integer-key order.
Both phases must finish successfully before the state becomes Succeeded. Async initializers run on
a background dispatcher, but still participate in startup and delay navigation until they finish.
Keep blocking work off the main dispatcher.
The current app registers:
| Phase | Key | Initializer | Purpose |
|---|---|---|---|
| Core | 0 | AppConfigInitializer | Initialize app configuration |
| Core | 1 | LoggerInitializer | Configure logging |
| Async | 0 | StartRouteInitializer | Resolve GetStartRoute and assign AppNavRoutes.Default |
Register an Initializer
Implement Initializer for setup on the main dispatcher, or AsyncInitializer for setup on the
default dispatcher. Both define suspend fun init() and can receive dependencies through Metro.
For example, the existing start-route initializer propagates a failed route lookup into the pipeline:
kotlin1import dev.zacsweers.metro.Inject2import io.baselines.sample.ui.navigation.AppNavRoutes3import io.baselines.sample.ui.navigation.GetStartRoute4import io.baselines.toolkit.initializer.AsyncInitializer56@Inject7class StartRouteInitializer(8 private val getStartRoute: GetStartRoute,9) : AsyncInitializer {1011 override suspend fun init() {12 AppNavRoutes.Default = getStartRoute(Unit).getOrThrow()13 }14}
Contribute the implementation to the appropriate map in the existing InitializersProvider in
app:multiplatform. Its start-route binding is:
kotlin1@Binds2@IntoMap3@IntKey(0)4val StartRouteInitializer.bind: AsyncInitializer
The binding annotations come from dev.zacsweers.metro. InitializersProvider uses
@ContributesTo(AppScope::class) and declares both maps with @Multibinds(allowEmpty = true):
Map<Int, () -> Initializer> and Map<Int, () -> AsyncInitializer>.
Choose an unused key within the target map; core and async maps may use the same keys. Lower keys
run first within their phase. Preserve the existing bindings when adding another initializer.
For a new module, follow Create New Module, then add it to
commonMain.dependencies in app/multiplatform/build.gradle.kts.
Starting and Observing Initialization
Android starts initialization in App.onCreate(); iOS starts it in the AppDelegate launch callback:
kotlin1appComponent.appInitializer.initialize()
initialize() returns without waiting for startup to finish and is safe to call from any thread.
The attempt runs in an app-owned coroutine scope, so cancelling the calling coroutine does not cancel it.
state: StateFlow<AppInitState> retains the latest state for existing and late subscribers:
| State | Meaning |
|---|---|
Running | Startup is pending, or an attempt is scheduled or running |
Succeeded | All core and async initializers completed successfully |
Failed(cause) | The attempt failed or was cancelled |
The initial value is Running, even before the first command. Collecting the flow does not start
initialization. Collectors remain subscribed across retries; slow collectors receive the latest state.
Failures and Retries
- A non-cancellation exception thrown by
init()is collected while the remaining initializers in that phase run. Their failures are attached as suppressed exceptions toAppInitializerException. - Any core-phase failure prevents the async phase from starting.
- An exception while obtaining an initializer from its provider stops the attempt and is exposed as the failure cause.
- Cancellation stops the attempt, publishes
Failed(cause), and releases the guard for a later retry. Initializers must propagateCancellationExceptionfrom broad exception handlers. - Calls while an attempt is scheduled or running are ignored without queuing or restarting work.
- A call after completion starts a new attempt, including after success. It publishes
Runningand reruns the full pipeline; it does not resume from the failed initializer.
Make initializer side effects safe to repeat. Earlier successful setup may already have taken effect when a later initializer fails.
Running, Failure, and Navigation Screens
MainViewModel.state() collects the initializer state with collectAsStateWithLifecycle().
ComposeApp renders the resulting MainUiState.ContentUm:
| Initialization state | Shared UI |
|---|---|
Running | AppInitRunningScreen, with a centered progress indicator |
Failed | AppInitFailureScreen, with a retry action |
Succeeded and a non-empty back stack | Navigation content through NavDisplay |
Succeeded and an empty back stack | No content while the start route initializes the stack |
Both startup screens live in app/multiplatform/.../ui and render outside navigation. Customize them
there; they need no route, navigation entry, or separate feature module. The failure screen dispatches
MainUiEvent.RetryInit, which MainViewModel handles by calling AppInitializer.initialize().
The start route is supplied to the navigator only after Succeeded. A restored stack can exist earlier,
but startup state still determines whether it is shown. See the
Navigation Guide for restoration behavior.
Platform Launch Screens
While the app initializes:
- Android shows the system splash screen.
- iOS shows
LaunchScreen.
On success, the app shows navigation content. On failure, it shows AppInitFailureScreen.
When the user taps retry, both platforms show AppInitRunningScreen while initialization runs again.